Skip to content

fix(react-native): capture fatal React render errors - #5293

Merged
turnipdabeets merged 8 commits into
mainfrom
fix/rn-render-error-capture
Oct 22, 2026
Merged

turnipdabeets merged 8 commits into
mainfrom
fix/rn-render-error-capture

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Problem

React render errors that crash the app no longer reach error tracking on React Native 0.78 and newer.

React 19 (RN 0.78+) reports an uncaught render error through the root onUncaughtError callback, which calls ExceptionsManager.handleException(error, true) directly. It never reaches ErrorUtils, the only hook posthog-react-native uses, so the SDK's fatal capture never runs. On React 18 (RN < 0.78) React rethrew render errors to ErrorUtils, so they were captured.

Reproduced on device, RN 0.86.2 new architecture, Release build:

  • Android: the app does not crash. The React host is torn down and the screen goes blank. No $exception is sent.
  • iOS: RN raises RCTFatalException and the app aborts. The plugin drops that native report as a duplicate of a JS capture that never happened, so no $exception is sent.

A customer reported this on iOS (RN 0.88-rc, Expo 58, new architecture). Related: #5271 makes iOS keep the native report when JS did not capture the crash.

Changes

  • Wrap ExceptionsManager.handleException (optional deep import of react-native/Libraries/Core/ExceptionsManager, feature-detected) so fatal calls go through the same tracker as the ErrorUtils handler. Render fatals then reach handleFatalException, with the same durable native capture and persistence wait as any other fatal. The mechanism stays onuncaughtexception.
  • The ErrorUtils hook stays the primary path. If the module is missing or can't be wrapped, only the render path is skipped.
  • Non-fatal calls (errors caught by an error boundary) pass straight through.
  • One capture per fatal: a handler fatal that RN's default handler forwards to handleException is not captured again. Fatals still being persisted are tracked per error, and a duplicate report of one waits until the original has been forwarded. A forwarded Error is not captured again when the handler PostHog wraps reports it to React Native later (Sentry flushes before calling the handler it replaced).
  • Web bundles resolve OptionalReactNativeExceptionsManager.web.ts, which imports nothing, so React Native's ExceptionsManager stays out of the web dependency graph. RN 0.79 and older have no web implementation of Libraries/Utilities/Platform, which it imports.
  • No new option. Gated by errorTracking.autocapture.uncaughtExceptions.

Rejected alternatives:

  • unstable_setExceptionDecorator: only runs inside reportException, which is skipped when native handles the exception, and it holds a single slot.
  • RN$handleException: a read-only global, and handleException calls it anyway.
  • console.error: RN logs the error there before reporting it, but it can't tell a fatal render error from one an error boundary caught.

Maintenance risk: if RN moves or stops exporting Libraries/Core/ExceptionsManager, render-crash capture silently stops; handler fatals are unaffected. The module has lived at that path from RN 0.70 through today's nightly (0.89), and RN 0.87 still exports ./Libraries/* after narrowing its exports map. A scheduled contract check against react-native@latest and @nightly is a possible follow-up.

Known gap: on RN 0.78 only, with Metro's non-default experimentalImportSupport: true plus inlineRequires: false, ErrorHandlers caches its named handleException import at startup, so render crashes bypass the wrap there. RN 0.79+ calls ExceptionsManager.handleException through the module object and is covered. Handler fatals are captured in every setup.

Composing with #5271

Both hooks feed one tracker, which calls ErrorTracking.onUncaughtException, where #5271 sets its marker. Whichever merges second needs at most a trivial rebase. With today's plugin, iOS sends one event (JS) because the native report is still dropped unconditionally.

Test plan

  • posthog-react-native vitest (77 files, 1347 tests, after merging main), lint, format and typecheck pass. New: test/render-error-capture.spec.ts and test/optional-exceptions-manager.spec.ts. These cover fatal vs non-fatal, dedup, a missing or throwing module, autocapture off, Metro marking the deep import optional, both module shapes, and nothing loading on web.
  • Device, Release builds, events checked in a test project. Each cell is the number of $exception events per crash; same-uuid pairs at ingestion are counted once.
Setup Render crash (main → this PR) Handler crash Error caught by a boundary
RN 0.86, new arch, Expo 57, Android 0 → 1 fatal, 7 JS frames 1 fatal generic error, no fatal
RN 0.86, new arch, Expo 57, iOS 0 → 1 fatal, 7 JS frames, no native duplicate 1 fatal not run
RN 0.79 (React 19), old arch, bare, Android + iOS 0 → 1 fatal, 23 JS frames 1 fatal 1 error
RN 0.79, old arch, Expo 53 + expo-updates, Android + iOS 0 → 1 fatal, 23 JS frames 1 fatal 1 error
RN 0.76 (React 18), old arch, bare, Android + iOS 1 → 1 (already captured; no double capture) 1 fatal 1 error
RN 0.86, Expo 57, Android, @sentry/react-native 8.32.0 set up before PostHog not run 1 fatal (2 before 9df50c6) not run
  • Combined with fix(react-native): stop dropping native crash reports for fatals JS did not see #5271, the setups above give exactly 1 event per crash: the JS event, with the native copy dropped by the marker.
  • Sentry row: the crash is thrown from a timer so it reaches ErrorUtils as a fatal, and Sentry's JS handler runs with enableNative: false.
  • Web: expo export --platform web, dev and production, passes on Expo 53 (RN 0.79.6) and Expo 57 (RN 0.86.2) with no ExceptionsManager in the bundle; the Expo 53 Android export still bundles it.
  • Not verified: RN 0.78 itself (see Known gap), RN's useAlwaysAvailableJSErrorHandling flag on, and Sentry set up after PostHog (that order forwards through RN's default handler synchronously, so the duplicate is skipped).

Release info Sub-libraries affected

Libraries affected

  • All of them
  • posthog-js (web)
  • posthog-js-lite (web lite)
  • posthog-node
  • posthog-react-native
  • @posthog/react-native-plugin
  • @posthog/react
  • @posthog/ai
  • @posthog/convex
  • @posthog/next
  • @posthog/nextjs-config
  • @posthog/nuxt
  • @posthog/openfeature-node-provider
  • @posthog/openfeature-web-provider
  • @posthog/rollup-plugin
  • @posthog/webpack-plugin
  • @posthog/types
  • @posthog/browser-common

Checklist

  • Tests for new code
  • Accounted for the impact of any changes across different platforms
  • Accounted for backwards compatibility of any changes (no breaking changes!)
  • Took care not to unnecessarily increase the bundle size

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Claude Code (Claude Opus 5.5) with subagents: shell, file edits, GitHub CLI, the PostHog MCP for reading test events, and Android emulator / iOS simulator runs of examples/example-expo-57.
  • Compared the hook options against RN 0.69 to 0.86 and nightly sources and chose the handleException wrap, keeping ErrorUtils as the primary path so a broken deep import can't cost handler fatals. One independent review pass found no blocking issues; it surfaced a dedup memory leak, fixed in the second commit.
  • Notes for reviewers: in __DEV__, LogBox's own internal failures can now be captured as fatals. Nothing logs when the deep import is unavailable.

🤖 Generated with Claude Code

Wrap ExceptionsManager.handleException so React 19 root onUncaughtError fatals reach the uncaught-exception fatal path.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed c819d3b: 2 Should fix.

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Coherence

4 spec problems · 0 failing chokepoints · 2 spec gaps · 0 guard failures

44 components, 7 bullets (5 invariants, 2 requirements), 4 problems

Spec problems (spec --check)

  • packages/ai/src/otel/Otel.spec.md:7 an invariant bullet starts with <name>: <sentence>
  • packages/ai/src/otel/Otel.spec.md:8 an invariant bullet starts with <name>: <sentence>
  • Posthog-js.practice.md:20 practice add an invariant cites 187a5c5, which is no commit in this repository
  • Posthog-js.practice.md:20 practice add an invariant cites 8922ab2, which is no commit in this repository

Chokepoints: none declared.

Spec gaps: 2 entrances with outside or unknown trust and no traced control, and 10 more held by the adoption baseline

  • packages/web/Web.spec.md entrance public api (trust host-app): packages/web
  • packages/webpack-plugin/Webpack-plugin.spec.md entrance webpack plugin (trust build): packages/webpack-plugin

Close one with guard: <chokepoint>, an invariant that names it on its entrances: line, or control: none — <reason>; scaffold control proposes it.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 6b29619: 1 Should fix.

@github-actions

Copy link
Copy Markdown
Contributor

This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the stale label – otherwise this will be closed in another week.

@github-actions github-actions Bot added the stale label Oct 21, 2026
@turnipdabeets
turnipdabeets marked this pull request as ready for review October 21, 2026 21:17
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 21, 2026 21:17
turnipdabeets and others added 2 commits October 22, 2026 10:42
…wards it later

Forwarded fatal Error objects pass straight through, so a previous handler that reports to React Native after it returns (Sentry) no longer causes a second capture.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 9df50c6: 1 Must fix.

This is review round 3 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

React Native 0.79 and older have no web implementation of the modules it imports, so Expo web bundling failed.
@turnipdabeets
turnipdabeets added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit b4dfdff Oct 22, 2026
73 of 74 checks passed
@turnipdabeets
turnipdabeets deleted the fix/rn-render-error-capture branch October 22, 2026 15:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants